查看原文
其他

干货 | 携程火车票Flutter最佳实践

火车票 携程技术 2022-06-07


作者简介

 
本文为联合撰稿,作者为携程火车票Flutter团队,致力于跨端快速、高性能开发。


背景

在竞争激烈的移动时代,各大互联网公司都在争相抢夺市场,如何提高研发效率,快速迭代产品成为非常重要的因素。

跨平台方案能够节约一定开发、测试、运维成本。Flutter是由谷歌开源的跨平台框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。

一、 为什么选择Flutter

携程在已经引入了 React Native 的情况下,为什么还会选择 Flutter?更多是对性能的考虑。开发效率与性能体验就像天平两端,需要找到一个平衡点。RN 能够满足我们绝大部分的业务,并且热更、版本控制都很灵活。但是在复杂页面上,特别是在长列表的渲染上,还是存在一定的问题,促使我们去尝试一些新的解决方案。Flutter官宣自绘UI引擎,采用原生方式做渲染,媲美原生体验。

Native 、React Native、Flutter 对比如下:


1.1 研发效率
      
Flutter具有跨平台性,可以在多端上运行。同时Dart语言作为开发语言,本身的优势就在于它既支持JIT,又支持AOT,在 JIT(Just In Time)即时编译功能下,能提供 Hot Reload 功能。在开发过程中,实时地看到界面改动。生产包AOT编译,将代码编译成 ARM 二进制,从而既可以享受运行时又具有原生语言相近的运行效率。


1.2 扩展性好

Flutter提供了多种不同的Channel,用于 Dart 和平台之间相互通信。通过这些桥方法,使Flutter具有很好地与 Native 和 React Native 进行混合编程的能力。赋予 Flutter 一些 Native 的能力,同时也能很好地让我们在现有 Native 项目混合Flutter开发。 

二、 Provider对MVVM架构的实践
      
在Flutter的开发过程中,特别是一些业务复杂的页面,为了代码结构清晰,模块逻辑解耦,我们一般采用的是模块化的编程思想。随之而来的问题就是,组件之间怎么相互通讯,比如变更了登录态,如何通知其他模块刷新?

推荐使用Provider来管理各个组件的状态,我们实践下来 ,主体布局采用MVVM模式是比较方便做模块化编程的。

2.1 为什么需要使用Provider

如果状态是该组件私有的,则应该由组件自己管理;但是如果状态要跨组件共享,则该状态应该由各个组件共同的父元素来管理。对于组件私有的状态很好理解,当需要刷新当前widget的时候,只需要通过setState()的方法来实现组件重绘的效果;对于跨组件共享的状态,可以使用EventBus来实现。

可是当事件多了的时候,难以正确管理,其次订阅者必须要显式注册状态改变回调,也必须在组件销毁的时候手动解绑以避免内存泄漏。而Provider就可以通过自身的原理,简单地去实现状态共享,不需要麻烦的操作。且Provider是官方推荐的状态管理方式,具有良好的生态环境及维护团队。

2.2 Provider的实现原理

1)  InheritedWidget简单介绍

Provider是基于InheritedWidget的再次封装,InheritedWidget提供了一种数据在Widget树中自上而下传递,共享的方式。我们在根Widget继承了InheritedWidget,然后在该组件中存放一个数据data,那么可以在任意子Widget中来获取该组件的数据并使用。当在任一组件中改变了共享数据data,InheritedWidget组件会自上而下通知所有使用过共享数据的组件并刷新组件,同时会回调didChangeDependencies() 方法。

2)  Provider的原理和流程


共享数据的Model变化后,会自动通知ChangeNotifierProvider,ChangeNotifierProvider内部会重新构建InheritedWidget,而依赖该InheritedWidget的子Widget就会更新。

2.3 Provider的使用方式

架构模式图如下:

 
1)创建业务ViewModel,在ViewModel内部存放需要共享的数据。ViewModel 继承Flutter SDK中提供的ChangeNotifier类,它继承Listenable,也实现了一个Flutter风格的订阅者模式,其内部实现了addListener(),removeListener()等方法,实现对订阅者的处理。同时最好复写dispose()和notifyListeners()方法,防止用户在调用数据时销毁界面,而等到数据获取到以后通知界面刷新导致Crash。

2)注册状态管理类,使用ChangeNotifierProvider或者MutiProvider将需要共享数据的Widget包起来,单个NotifierProvider时使用ChangeNotifierProvider,多个NotifierProvider时使用MutiProvider包装,如下:

///多个NotifierProvider的时候return MultiProvider(providers: [ ChangeNotifierProvider(create: (context) => dataViewModel(mCommonAdvancedFilterRoot,query)), ChangeNotifierProvider(create: (context) => UserPreferentialViewModel(query)), ChangeNotifierProvider(create: (context) => UserPromotionViewModel())///需要调用共享数据的子Widget], child: ListResearchPageful(query));

3)在被包起来的Widget中的任一子组件中获取共享数据ViewModel,可以在StatefulWidget中的builder()方法中获取,也可以使用Builder组件进行获取,如下:

///在StatefulWidget中的build()方法中获取ViewModelclass ListResearchPageState extends TripState<ListResearchPageful> {@override Widget build(BuildContext context) {///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在HotelListView方法下的唯一位置获取ViewModel var listViewModel = Provider.of<ListDataViewModel>(context); var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context); return MediaQuery( child: QueryListPage(widget.query, ListDataViewModel, userPromotionViewModel)); }}

///借用Builder组件进行获取ViewModel@overrideWidget build(BuildContext context) {///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在ListView方法下的唯一位置获取ListDataViewModel var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context); return MediaQuery( child: Builder(builder: (context) {var listDataViewModel = Provider.of<ListDataViewModel>(context); return queryListPage(widget.query, listDataViewModel, userPromotionViewModel); },));}

4)获取到ViewModel后,可以在子组件中直接使用viewmodel中的共享数据,如下:

//领券监听///此处可以直接使用viewModel调用viewmodel中的方法Event.addEventListener( "UPDATE_QUERY_RESULT_LIST",(eventName, eventData) { if (isOnPause) { listViewModel.isNeedRefresh = true; listViewModel.refreshListData(listViewModel.query); } else { listViewModel.refreshListData(listViewModel.query); }});

2.4 Provider的优势

1)我们的业务代码更专注数据,只要更新Model,UI就会自动更新,不用在状态改变后再去手动调用setState()来显示更新页面。

2)数据改变的消息传递被屏蔽时,我们无需手动去处理状态改变事件的发布和订阅,provider自行处理。

3)在大型复杂应用中,尤其是需要全局共享的状态非常多时,使用Provider将会大大简化代码逻辑,降低出错的概率,提高开发效率。

三、Flutter 性能调优

一个新技术改造完成,我们最关注的当然是性能体验有没有达到预期。那Flutter页面性能评判标准是什么,如何去度量,有没有可视化工具,帮我们去做一些性能调优。

3.1 Flutter渲染原理简介

在做性能优化之前,先让我们了解一下渲染的原理。Flutter的一切皆为Widget。为了性能又区分了 StatefulWidgetStatelessWidget。StatefulWidget 能通过setState()来实现刷新。这样的设计方便我们去控制局部刷新,从而提高性能。

Flutter 中的控件会历 Widget -> Element -> RenderObject -> Layer 这样的变化过程,而其中 Layer 的组成由 RenderObject 中的 isRepaintBoundary 标志位决定。

当调用 setState() 时,RenderObject 就会往上的父节点去查找,根据 isRepaintBoundary是否为 true,会决定是否从这里开始往下去触发重绘,来确定要更新哪些区域。

3.2 构建运行Profile模式 
  
Flutter 支持三种模式编译 app,Debug模式、Release模式和Profile模式。Debug 模式 采用JIT编译,支持HotReload,所以在Debug模式下会放大性能问题。性能分析需要确保使用真机并在profile模式下运行,这样拿到的数据是最接近真实性能的。

1)Debug 模式对应 Dart 的 JIT 模式,可以在真机和模拟器上运行。该模式会打开所有的断言,以及所有的调试信息、服务扩展和调试辅助。此外,该模式支持有状态的 Hot reload。

2)Release 模式对应 Dart 的 AOT 模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小。

3)Profile 模式,基本与 Release 模式一致,只是多了对 Profile 模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖。该模式用于分析真实设备实际运行性能。

  • 纯 Flutter 项目构建 Profile 模式


flutter run —profile 命令是使用 Profile 模式来编译的。IDE 也是支持这个模式的,例如 Android Studio 提供了 Run > Profile… 菜单选项。

  • Flutter 与 Native 混合项目构建 Profile 模式


a. 打包Flutter工程Profile产物

// 进入flutter项目,执行build-release,并指定输出目录 tripflutterbuild-release -o /projects/ctrip_flutter/release -i info

b. 配置Native项目

打包好flutter产物之后,需要导入到native项目并打包。修改Native项目根目录的gradle.properties文件。

### 开启Profile模式 TRIP_FLUTTER_PROFILE=true ### 设置profile模式下js使用的产物目录(过程1构建的 ./profile 目录)TRIP_FLUTTER_LOCAL_OUTPUTS_PATH=/projects/ctrip_flutter/profile

c. 构建Native工程

直接通过IDE运行到手机上。

3.3 性能分析工具及方法

1)performance overlay 

平时常用的性能分析工具有performance overlay,通过它可以直观看到当前帧的耗时。在Profile模式下,通过Android Studio 看页面的FPS,注意需要在HotReload 连接的情况下查看。

选中 View > Tool Windows > Flutter Performance。


点击上面图中的箭头所指的按钮,就会在手机或模拟器中打开(如下图所示)。FPS是一个动态过程,页面滑动这个值是一直变化的,最右边的是当前帧。出现红色则表示耗时超过16.6ms,也就是发生丢帧现象,也是我们常说的页面闪动问题。performance overlay的主要功能如下:

  • 获取FPS数值来衡量页面性能,方便对比Flutter、Native页面帧率;

  • 直观统计页面在各个机型上面的表现;

  • 定位页面的具体哪个模块有问题;



2)Dart DevTool

另一个工具是Dart DevTool ,在Android studio右侧,还可以从Flutter inspector里面的more action,以及Flutter Performance底部的入口进入。

目前DevTools支持的功能有如下一些:

  • 检查和分析应用程序的UI布局和状态。

  • 诊断应用的UI 性能问题。

  • 检测和分析应用程序的CPU使用情况。

  • 分析应用程序的网络使用情况。

  • Flutter或Dart应用程序的源代码级调试。

  • 调试Flutter或Dart应用程序的内存使用情况和分析内存问题。

  • 查看运行的Flutter或Dart应用程序的一般日志和诊断信息。


3.4 实战性能技巧

1)懒加载ListView

推荐使用ListView.builder()构建List,这样当Item滚入屏幕时才创建Item,而不是ListView-children,这样会立刻创建所有的Item。

///Bad code 不推荐使用children 构建ListListView(children: getItems(mList))List<Widget> getItems(List<FilterNode> mList){ List<Widget> items=new List<Widget>(); if(null!=mList){for(Node node in mList){items.add(Text("不推荐写法"));} } return items;}
///推荐写法ListView.builder(// physics: NeverScrollableScrollPhysics(),//shrinkWrap: true,itemCount:mList.length,itemBuilder: (BuildContext context, int index) {return Text("推荐使用ListView.builder()");}))

注意,无论是ListView还是GridView,只要是设置了shrinkWrap: true属性,都没有了懒加载的效果了。

2)控制刷新范围与次数

  • 尽量避免在滑动监听中触发setStat()刷新视图。



如上图所示,需要滑动的过程中,显示、隐藏标题栏,并且是一个渐变的过程,遇到这种情况,一定要尽量的控制刷新的范围和频次。控制在只在头图可见的情况下面触发setStat(),避免不必要的页面滑动触发刷新。

scrollController.addListener(() {if (scrollController.offset > scrollHeight && titleAlpha != 255) { setState(() {titleAlpha = 255; }); }
if (scrollController.offset <= 0 && titleAlpha != 0) { setState(() {titleAlpha = 0; }); }
if (scrollController.offset > 0 && scrollController.offset < scrollHeight) { setState(() {titleAlpha = scrollController.offset * 255 ~/ scrollHeight; }); }});

  • 尽量将setStat()放在放置于视图树的低层级,好处是build时影响范围极小,简称局部刷新。



如上图所示在列表中 Item 中存在大量的倒计时。一定要控制刷新倒计时只影响控件本身,并且只有可视的区域视图是在刷新的,不可见的情况下及时销毁计时器。一直刷整个列表,性能开销是恐怖的。

Widget build(BuildContext context) {return Text(timeRemaining, style: TextStyle( color: HotelColors.hotel_list_reduction_sale_color, fontSize: 10, fontWeight: FontUtil.mediumWeight));}

3)避免组件重复创建

能复用的组件尽量复用,特别是在组件化编程,页面级的情况下面,每次刷新页面把所有的子组件都重新渲染一遍,性能开销也是很大的。尽量复用,避免不必要的视图创建。

///存放界面所有的widgets,用以缓存List<Widget> widgets = new List<Widget>();///因为头部布局是静态的不刷新,使用变量控制是否复用以前的widgetsvar refreshPage = true;///获取界面布局所有的widgetsList<Widget> getPageWidgets(ScriptDataEntity data) {if(widgets.isNotEmpty && !refreshPage) { return widgets; }}

四、Flutter 布局技巧
 
4.1 Flutter 不可见组件预加载

Flutter 一些组件基本都是有懒加载的,不可见的组件是没有渲染视图的,这样滑动过去,有用到网络图片的地方,经常会先白一下。针对这种情况我们对将要加载的图片进行预加载处理,比如列表页在分页请求数据回来的时候做图片预加载。还有,下一个页面的图片,需要一进去就有图片直接显示,就可以在当前页面做图片预加载。

预加载           
未预加载

代码如下所示:

///对每一页加载的数据进行做图片预加载(hotelListViewModel.currentPageHotels ?? []).forEach((element) {var logo = element?.logo ?? ""; if (StringUtil.isNotEmpty(logo)) { precacheImage(NetworkImage(logo), context); }});

当数据出来后使用PreChcheImage()预加载处理图片链接,以保证当用户滑动图片以后不会看到图片加载白屏这种问题。

4.2 Flutter 数据预加载

为了缩短用户的加载等待时长,我们经常需要一些预加载方法。比如在前一个页面预加载下一个页面的数据,或者在长列表的分页请求时候,可以做分页预加载。比如当你滑动到第五个可见的时候,就提前把下一页的数据加载好。
 
列表页通过桥方法获取上一个页面预加载的数据,这样就能有一个直出体验,这里要考虑数据已经加载好、加载中、加载失败的情况。同时还要考虑,缓存数据的时效性,什么情况下需要删除缓存。

///请求列表数据数据void loadListData(HotelQuery query) {///在首页提前获取列表页的数据并缓存到本地,当用户进入列表页时可以直接展示数据 if (resultModel != null) { ///判断是否需要再次请求数据 _dealWithResult(resultModel); return; } else if (isPreloading) { ///通过桥方法获取首页已经缓存的数据 HotelBridge.getListCache<Map>({'queryModel':query.toJson()}) .then((resp) { final newResultModel = QueryResultModel.fromJson(resp); ///有缓存数据直接处理使用 _dealWithResult(newResultModel); }).catchError((error) { ///没有数据采取请求列表页的数据 getHotelList(); }); }}

4.3 布局自适应高度

如果需要根据内容填充的高度来自适应左边图片的高度,目前Flutter并不支持该功能,我们可以借助IntrinsicHeight组件来完美地解决该问题。InstrinsicHeight可以让同一行的子widget都是相同的高度。


  • 可以将需要自适应高度的Widget使用ConstrainedBox进行包裹,并设置最低高度;

  • 将图片作为Container的背景图片,使用DecorationImage进行修饰当前的Container;

  • 将图片的填充方式设置为BoxFit.Cover或者fillHeight即可;


五、Flutter 中常见问题分析及解决方案

5.1 设置State引起的问题

1)错误展示信息:

NoSuchMethodError: The method  markNeedsBuild  was called on null。

2)错误分析

这个错误一般情况下出现在异步任务,比如一些界面请求网络数据,异步获取本地数据等,需要根据数据的状态来改变刷新Widget State。异步任务结束在页面被销毁之后,没有检查State是否还是mounted状态,继续setState()就会出现这个错误。错误代码如下所示:

///从服务器端获取当前活动终止时间,当服务器返回以后,会通知刷新这里///如果用户在数据返回之前销毁该界面,等数据回来以后刷新界面就会报错final endTime = roomDetailItemEntity?.tonightEndTime ?? '';int endTimeOfNum = 0;if (endTime.isNotEmpty) { try { endTimeOfNum = int.parse(endTime) ?? 0; if(endTimeOfNum - Util.currentTimeMillis() > 0) { this.setState(() { _showCountDown = true; }); } } catch (e) {}}

3)处理办法

在调用setState()方法之前检查是否mounted,mounted是一个标示当前Widget树是否已经被渲染的状态值。所以mounted检查很重要,只要涉及到异步还有各种回调的时候,都不能忘记检查该值。如下:

final endTime = roomDetailItemEntity?.tonightEndTime ?? '';int endTimeOfNum = 0;if (endTime.isNotEmpty) { try { endTimeOfNum = int.parse(endTime) ?? 0; if(endTimeOfNum - Util.currentTimeMillis() > 0) { if(mounted) { this.setState(() { _showCountDown = true; });}}} catch (e) {}}

5.2 使用MediaQuery.of()动态获取屏幕属性的问题

1)错误展示信息

BoxConstraints has a negative minimum width;

2)错误分析

这种情况一般出现在需要获取屏幕宽度,根据屏幕宽度减去另外一个组件的宽度,用来设置另外一个组件的宽度导致,在一些计算速度比较低的手机,可能获取到的屏幕宽度为0,这样就会导致你的组件的宽度为负数,报出错误异常。如下所示:

Widget hotelListDesContent(BuildContext context) {return Container(///此处想实现左边是图片,右边是相关信息的布局,如果MediaQuery.on(context).size.width获取为0时,就会报出异常 width: MediaQuery.of(context).size.width - Dimens.image_width80, ///右边内容 child: Stack(children: [ Container(child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[ hotelListDesName(), englishName(), hotelListRemarkContent(),],),), ///左边图片 Positioned(child: fullRoomItem()), ],));}

3)处理方式

尽量使用Expand,Flexible,Flex,Wrap,Stack等组件配合Column,Row进行动态布局设置组件的宽高等。如下所示:

Widget hotelListDesContent(BuildContext context) {return Expanded( flex: 1,child: Stack( children: [Container( child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[ hotelListDesName(), englishName(), hotelListRemarkContent(),],),), Positioned(child: fullRoomItem()), ], ));}

5.3 使用Provider时,未判断界面状态通知界面刷新的问题

1)错误信息展示

Null check operator used on a null value;

2)错误分析

一般情况下出现这种问题是由于界面销毁后,继续调用notifyListeners()方法通知界面刷新引起的bug。当用户打开一个界面,我们发送了API请求,此时用户销毁了界面,我们并未监听,等到数据返回以后,强行通知界面刷新,导致Crash。如下所示:

HotelServices.getTyHotelRoomPrice(params, ApiCallBack(onSuccess: (Object obj) {this.roomPriceEntity = HotelRoomPriceEntity.fromJson(obj); this.resultCode = 1; ///如果在数据返回是,用户已经关闭当前界面,此处通知刷新界面会导致crash notifyListeners();}, onError: (int code, String message) {} notifyListeners()}));

3)处理方式

正常情况下,我们会写一个基类继承ChangeNotifier,在内部重新复写dispose()方法,同时重新封装方法通知刷新界面,在每次需要通知刷新界面的时候判断当前界面是否已经被销毁。如下所示:

import 'package:flutter/cupertino.dart';/// ViewModel基类class HotelViewModel extends ChangeNotifier{ bool _disposed = false; @override void dispose() { _disposed = true; super.dispose(); } void hotelNotifyListeners() { if(!_disposed){ notifyListeners(); } }}

5.4 使用Text.rich时导致的问题

1)错误信息展示:UnimplementedError

2)错误分析

出现这个问题的原因在于使用Text.rich来展示多个Span组件时,如果设置了最大行数,当组件超过最大行数,有别的组件未成功展示时,再次点击当前widget,使它接受时间,就会导致crash,用户的感知为操作无响应,其实已经crash。如下所示:

///母房型名称, 当前我们Text最大显示两行,当大于两行是,出现...,可是此时第二个组件无处显示,当用户点击就会crashRow(children: <Widget>[Expanded(child: Text.rich(TextSpan( children: [TextSpan( text: itemRoomEntity.baseName ??""), WidgetSpan( child: Container( padding: EdgeInsets.only(bottom: Dimens.gap_dp3), child: Icon(HotelIcons.show_more), ), ), ]), maxLines: 2, overflow: TextOverflow.ellipsis), ),], crossAxisAlignment: CrossAxisAlignment.center,),

3)解决办法

使用Flexible代替Expanded,直接使用Text即可,区别在于Flexible不会自动填充整个剩余宽度,如下所示:

///母房型名称Row( mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[Flexible(child: Text((childCount > 1)?itemRoomEntity.baseName ?? "":"", maxLines: 2, overflow: TextOverflow.ellipsis,),),Container(child: Icon(childCount ==1?HotelIcons.show_more:null), margin: EdgeInsets.only(top: Dimens.gap_dp2),), ], crossAxisAlignment: CrossAxisAlignment.center,)

六、总结与展望

总结一下,本文我们介绍了选择Flutter的初衷,Provider 状态管理的实际使用,建议Flutter主体的构架采用MVVM模式,还介绍了一些Flutter性能检测、量化工具和一些性能优化点供大家参考。收集了Flutter开发过程中常见并且大量发生的问题,并提供了相应的解决方案。

在复杂业务和长列表上面体验,确实 Flutter 优于 React Native。但是React Native 也有它的优势,比如灵活的版本迭代。没有最好的跨平台方案,只有最合适业务的。目前来说,Flutter还处于早期阶段,随着Flutter2.0的重大升级,其跨平台能力、性能、生态系统将会蓬勃发展,还是很值得尝试的。后续我们也将有更多的业务接入Flutter。

【参考文档】
 
[1] Flutter开发文档
https://flutter.cn/docs/perf/metrics
[2] Tripflutter开发文档
http://pages.release.ctripcorp.com/trip-flutter/docs/
[3] 咸鱼技术
https://developer.aliyun.com/group/idlefish?spm=a2c6h.12873639.0.0.2c9618dd4mdBAQ#/?_k=khoksz
[4] Flutter实战
https://flutter.cn/docs/perf/metrics
[5] 美团技术
https://tech.meituan.com/

团队招聘信息

我们是携程火车票研发团队,负责火车票业务的开发以及创新。火车票研发在多种交通线路联程联运算法、多种交通工具一站式预定、高并发方向不断地深入探索和创新,持续优化用户体验,提高效率,致力于为全球人民买全球火车票。
 
在火车票研发团队,你可以和众多技术大牛一起,真实地让亿万用户享受你的产品和代码,提升全球旅行者出行体验和幸福指数。
 
如果你也热爱技术,并渴望不断成长,火车票研发团队期待与你一起高速前行。目前我们前端、后台、算法、大数据、测试等技术岗位均有职位。
 
简历投递:tech@trip.com  邮件标题:【姓名】-【携程火车票】-【投递职位】


【推荐阅读】



 “携程技术”公众号

  分享,交流,成长



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存